Skip to content

feat(routing): automatic prompt caching + a per-session routing budget#982

Open
Spherrrical wants to merge 15 commits into
mainfrom
musa/cache-aware-routing
Open

feat(routing): automatic prompt caching + a per-session routing budget#982
Spherrrical wants to merge 15 commits into
mainfrom
musa/cache-aware-routing

Conversation

@Spherrrical

@Spherrrical Spherrrical commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Routing wants to move traffic to the best model; provider prompt caches are per-model and only pay off when a conversation stays put. Every reroute silently throws away a warm cache and re-bills the whole prompt at the uncached rate — so a "cheaper" model can actually cost more.

This PR lets the two coexist: caching keeps conversations warm and cheap, and a per-session routing budget decides whether a proposed switch is worth the cache it gives up. The budget is a routing concern — it applies whether or not prompt caching is enabled — and it never overrides the router's quality decision.

Cache lookup

Independent of any cost decision, every turn Plano resolves which session a request belongs to and whether that session's provider cache is still warm:

  • Session key. An explicit X-Model-Affinity header always wins. Otherwise Plano derives an implicit key by hashing (FNV-1a) the parts of the prompt that stay byte-for-byte identical across turns: the system prompt, the tool definitions, and the first user message. Conversation history grows at the tail, so those pieces don't change — turns 2+ hash to the same key. The first user message is in the key on purpose: without it, every session of the same agent (same system prompt + toolset) would collapse onto one pin and fight over a single model. The implicit key is derived whenever either prompt-caching affinity or the routing budget is active.
  • Binding store. The key looks up the session's binding — in-memory LRU by default, or Redis when replicas need to share it. The binding records the anchor model/route, a prefix hash, last-used time, cached-token estimate, and the running never-switch baseline and switch spend.
  • Warmth. Rather than depend on a post-response cache-hit signal, Plano infers warmth from the idle gap (now − last_used) against the provider's cache window. This is why the same decision runs on both the full-proxy path and the /routing decision endpoint, where Plano never sees the provider's response.
  • Drift. Plano stores a hash of just system + tools — the true cacheable prefix. If it changes mid-session, the provider cache is already gone: the session is treated as cold and re-binds.

How it works

1. Automatic prompt caching (opt-in, zero-config)

OpenAI-family models cache a repeated prefix automatically. Anthropic-family models don't — they only cache if the request explicitly marks where the cacheable prefix ends, via a cache_control breakpoint. So for Anthropic-family upstreams — including OpenAI-compatible gateways that front Anthropic, like DigitalOcean — Plano adds that breakpoint to the outgoing request itself. The upshot: a client that only speaks the OpenAI format still gets prompt caching on Anthropic models without having to know cache_control exists.

2. Routing budget

The router still runs every turn. When it proposes a different model than the one a session is warm on, Plano calculates the actual input-token cost of the switch:

switch_cost_in_usd = context_tokens × (candidate_uncached_input_rate − anchor_cached_input_rate) / 1M

This is computed from input-token pricing only — output-token cost is deliberately excluded, since output length is unknowable before generation and betting on it is unreliable.

  • Cheaper switch (switch_cost ≤ 0) → free, always allowed. It never reduces the session's switch spend — the "saving" is vs a path we didn't take, not real money.
  • Paid switch → its cost accrues into the session's cumulative switch spend, and it's allowed only while that spend stays within max_overhead_pct% of the session's running never-switch baseline (what staying on the anchor would have cost). Once the cap is reached, Plano sticks. The promise: a conversation bills at most max_overhead_pct% above never-switching.

The cap is yours to set; there are no baked-in defaults, and startup fails if it's configured without a max_overhead_pct (or without a cost source).

3. Observability for evals

Every decision is traceable. On the routing span:

  • plano.cache.warm — whether the session's provider cache was warm this turn
  • plano.cache.idle_ms — idle gap (now − last_used) the warmth call was based on
  • plano.switch.cost_in_usd — actual input-token cost of the proposed switch (negative when the candidate is outright cheaper)
  • plano.switch.overhead_ceiling_in_usd — spend headroom for this switch (max_overhead_pct% × baseline)
  • plano.switch.decisionallowed or retained
  • plano.switch.counterfactual_route(optional) on a vetoed switch, the road not taken, for A/B analysis
  • plano.session.overhead_pct — cumulative switching overhead consumed, as a % of the never-switch baseline (compare directly to max_overhead_pct)
  • plano.session.switch_spend_in_usd — cumulative $ spent on switches this session
  • plano.session.baseline_in_usd — cumulative $ staying on the anchor would have cost (the denominator)
  • plano.session.switches — switches taken so far this session
  • plano.session.total_cost_in_usd — cumulative actual conversation cost (input + output), refined from real usage each turn

Per-request cost also lands on each LLM span — llm.usage.input_cost_usd, llm.usage.output_cost_usd, llm.usage.total_cost_usd — so a session's turns sum to its total. On the metrics side, brightstaff_session_switch_decisions_total carries a reason label (same_anchor / free / within_cap / over_cap / no_pricing).

Config

The routing budget lives under routing and is independent of prompt caching. Presence of the block turns it on.

model_metrics_sources:          # required: feeds per-model input + cached-read rates
  - type: cost
    provider: models.dev

prompt_caching:
  enabled: true                 # automatic caching + session affinity (separate concern)

routing:
  routing_budget:               # no default — presence turns it on
    max_overhead_pct: 20        # bill at most 20% above never-switching (0 = never pay to switch)
    # replenish_on_rebind: true # reset running totals when a cold session comes back (default true)
    # cache_read_discount: 0.1  # assumed cached rate when a feed omits it (default 0.1)
    record_counterfactual: true # log the switch we *would* have made (telemetry only)

Opt out per request with X-Plano-Cache: off. Full walkthrough + guide under demos/llm_routing/routing_budget/.

Notable design choices

  • Input-cost only. Output-token savings are never credited — output length is unknowable before generation, so betting on it is unreliable.
  • Quality and cost stay separate. The router picks the best model; the budget only vetoes switches the session can't afford.
  • Routing budget is independent of caching. It lives under routing and applies whether or not prompt_caching is enabled.
  • One router, two entry points. session_router::route() is shared by the proxy and decision endpoints, so they can't drift apart.

Test plan

  • cargo test --lib (brightstaff + common + hermesllm) — pass (warmth truth table, budget depletion / free-credit / cold-reseed, handler parity, pricing regression)
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • Demo config validates against plano_config_schema.yaml
  • Manual: multi-turn session stays warm with no header; a paid over-budget reroute is vetoed while a cheaper one is allowed; X-Plano-Cache: off disables per request

Reconcile prompt caching with intelligent routing: zero-config implicit
session affinity, cache_control preservation/injection across transforms,
cache-adjusted routing economics, and a response-driven cache-hit feedback loop.
# Conflicts:
#	crates/brightstaff/src/handlers/llm/mod.rs
#	crates/brightstaff/src/router/model_metrics.rs
#	crates/common/src/configuration.rs
@Spherrrical Spherrrical changed the title feat(routing): cache-aware routing and zero-config prompt caching feat(routing): prompt caching, session stickiness, and cache-regret cost gate Jul 7, 2026
Add opt-in session_stickiness.record_counterfactual that emits the
plano.switch.counterfactual_route OTEL attribute when the cache-regret
gate retains the previous model, capturing the route it would have taken
had the switch been allowed. Telemetry only; the candidate is never
dispatched. Includes schema, demo config, and a getting-started guide.
@nehcgs

nehcgs commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@Spherrrical Thanks for pushing this PR!

  1. Does it only support OpenAI and Anthropic? How do we know if the prompt caching is enabled for each request from other model providers or server side?
  2. Refactor needed:
    • Too many files are changed. Please group session sticking logic in one place and prompt caching handling logic in another place to improve readability.
    • For this file crates/brightstaff/src/handlers/function_calling.rs, I'm curious why it's still here and being updated. Didn't we deprecate the function calling completely? We don't use Arch-Function any more, right?
    • Besides function calling, why is this file crates/brightstaff/src/session_cache/memory.rs also being updated? What does this memory cache refer to?
  3. Also, please update the summary of this PR as well.

…sive modules

Extract the two concerns interleaved in the LLM handler into dedicated
sibling modules for readability: prompt_caching.rs (cache-marker injection)
and session_stickiness.rs (session key/prefix-hash resolution, pin lookup,
cache-regret gate, and pin planning). handlers/llm/mod.rs is now a thin
orchestrator that calls them at clear phase boundaries. Behavior-preserving.
@Spherrrical

Copy link
Copy Markdown
Collaborator Author

@nehcgs

  1. Does it only support OpenAI and Anthropic? How do we know if the prompt caching is enabled for each request from other model providers or server side?

It's not limited to OpenAI + Anthropic. The strategy is resolved per (gateway × model family × upstream API) in cache_marker_strategy: OpenAI-family caches automatically (no markers) across OpenAI/Azure/etc.; Anthropic-family gets native Messages breakpoints or, behind OpenAI-compatible gateways (DigitalOcean, OpenRouter), content-part cache_control; unimplemented backends (Bedrock) are an honest None. Crucially, we don't assume caching worked, we read it back from each provider's own response usage (prompt_tokens_details.cached_tokens / cache_read_input_tokens), normalize it, and surface it per request as llm.usage.cached_input_tokens on the span plus brightstaff_prompt_cache_requests_total{provider,model,outcome=hit|miss}.

  • Too many files are changed. Please group session sticking logic in one place and prompt caching handling logic in another place to improve readability.

Done!

  • For this file crates/brightstaff/src/handlers/function_calling.rs, I'm curious why it's still here and being updated. Didn't we deprecate the function calling completely? We don't use Arch-Function any more, right?

The only change there is a one-line because the caching work added two fields to the shared Usage struct so every existing literal had to initialize them. It's not a revival of Arch-Function. The handler is technically still wired (/function_calling route), so if it's truly deprecated we can remove it in a separate cleanup PR rather than bundling that here.

  • Besides function calling, why is this file crates/brightstaff/src/session_cache/memory.rs also being updated? What does this memory cache refer to?

It changed because session stickiness introduced stale pins: get() now returns CacheLookup { route, is_stale } and entries linger for ttl × STALE_TTL_FACTOR so an expired pin can still be handed to the cache-regret gate as the "previous route." this one is core to the feature (Redis backend got the same change for parity).

  1. Also, please update the summary of this PR as well.

Done!

@salmanap

salmanap commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Intelligent routing and prompt caching pull against each other: routing wants freedom to move traffic across models, while provider caches are per-model and only pay off when a conversation stays put. Every reroute silently abandons a warm cache and re-bills the whole prompt at the uncached rate — so a "cheaper" model can end up more expensive. This PR makes the two work together, without letting caching hijack routing quality.

  • Automatic prompt caching (opt-in, zero-config). With no X-Model-Affinity header, Plano derives a stable session key from the prompt prefix (system + tools + first user message) so multi-turn conversations stay pinned and warm. Model-aware cache-control markers are auto-injected — Anthropic breakpoints or OpenAI content-part cache_control, idempotent and threshold-guarded — so OpenAI-shaped clients get caching on Anthropic-family upstreams. Caching never influences which model routing selects.

Why only the first user message in determining the prompt prefix? Separately, I don't follow this notion of "auto injected". That whole sentence is slop.

  • Session stickiness + cache-regret cost gate. Sticks to the pinned model by default. When a re-route is triggered (pin expired / prefix drift) and the previous model's cache is plausibly still warm, Plano weighs the input-cost regret of switching — context_tokens × (candidate_uncached_rate − previous_cached_rate) — against a developer-defined threshold, and either switches or retains the previous model. Input-only by design (no output-token gamble); negative regret always switches; drifted prefix means the cache is already cold, so the switch is free. Fully opt-in with no baked-in numbers (max_regret_usd or max_regret_pct_of_cached). The router still decides which model is better — the gate only vetoes a switch that isn't affordable.

How would we know that the cache is "plausibly" still warm? And I don't follow this whole regret cost gating math one bit. Not clear to me, how will it be clear to the developers building with plano.

  • Per-model pricing. Expose structured input + cached-read rates from the cost feed (models.dev cache_read, with a cache_read_discount fallback for feeds like DO that omit it) to drive the regret math; blended cost ranking is untouched.
  • Feedback loop. Response usage drives pin-after-hit, prefix-drift re-routing, and pin validation.
  • Telemetry. brightstaff_session_switch_decisions_total{decision}, pin-event and prompt-cache hit/miss counters, plus routing-span attributes plano.switch.regret_usd, plano.switch.threshold_usd, and plano.switch.decision — so a trace shows exactly why a request switched or stuck.
  • Optional KV-aware replica stickiness for self-hosted backends via endpoints.<name>.prefix_affinity (ring-hash on x-plano-prefix-hash), independent of prompt caching.

I am not sure what this means? how does this work? what does the developer have to do? Separately I am not sure how can you compute prefix trees unless you are storing the prompt prefix somewhere in a database

Please share the user experience in the config.yaml here. Don't link to the demo.

Sample config + walkthrough under demos/llm_routing/session_stickiness/.

Test plan

  • cargo test --lib (brightstaff + hermesllm + common) — pass
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cd cli && uv run pytest — schema/template render
  • Manual: multi-turn session stays pinned with no header; an expensive re-route is vetoed by the gate while a cheap one is allowed; X-Plano-Cache: off disables per request

@salmanap salmanap self-requested a review July 8, 2026 00:17
Replace observed_cache_hit + per-request threshold with time/provider-TTL
warmth and a per-session switch budget in a single session_router::route()
used by both the full-proxy and /routing decision paths.
@Spherrrical Spherrrical changed the title feat(routing): prompt caching, session stickiness, and cache-regret cost gate feat(routing): automatic prompt caching + session stickiness with a switch budget Jul 8, 2026
@nehcgs

nehcgs commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

@Spherrrical

  • Need to improve naming, making them accurate and easy to understand, e.g., switch_budget -> routing_budget and plano.switch.cost_usd -> plano.switch.cost_in_usd
  • Also, should not put routing_budget under session stickiness or prompt caching. It's independent, no matter prompt caching is enabled or not. We can always apply this routing decision logic. Keep it in the place where we define routing. You may need to update the logic of your code as well.
  • For the summary, describe it accurately.
    • We don't "estimates the cost of abandoning that cache:". Instead, we calculate the actual cost based on the input token cost only and don't take output token cost into consideration. Please make it clear.
    • Also, recommend to have an independent section to talk about cache lookup.

@Spherrrical Spherrrical changed the title feat(routing): automatic prompt caching + session stickiness with a switch budget feat(routing): automatic prompt caching + a per-session routing budget Jul 8, 2026
@nehcgs

nehcgs commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@Spherrrical

  • Why cheaper switches can refund the budget? It just means that it's free to switch?
  • I saw you created a new field routing. So I'm curious how we know if the routing is enabled or not before. With routing_budget, we can set it as 0 indicating routing is disabled, or unlimited indicating it's enabled.
  • Could you please try some examples to study how the routing_budget affects the user experience and give a guideline on how to set the value?

@Spherrrical

Spherrrical commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

@nehcgs

  • Why cheaper switches can refund the budget? It just means that it's free to switch?

Those are actually two distinct effects, and both apply:

Free = the switch is allowed regardless of budget. Any switch with cost <= 0 is permitted unconditionally and it never touches the remaining budget as a gate. This is the SWITCH_REASON_FREE branch.

Refund (credit_negative, default true) = on top of being allowed, we grow the remaining budget by the magnitude of the savings (budget -= cost, in which cost is negative).

The refund exists because the budget is meant to be a running ledger of the session's net switch cost. switch_cost = context_tokens × (candidate_uncached_rate − anchor_cached_rate). A negative cost means the candidate is cheaper than staying on the warm anchor which is the session banked savings. Crediting that back lets those savings offset a later beneficial but paid switch, so a session that routes through a cheap model for a while earns headroom for a premium switch later. If we didn't refund, the budget would only ever deplete and the policy would drift more/

  • I saw you created a new field routing. So I'm curious how we know if the routing is enabled or not before. With routing_budget, we can set it as 0 indicating routing is disabled, or unlimited indicating it's enabled.

Routing is an already existing top level object, we're just adding routing_budget to it now. We've set it to presence based for enable/disable. If routing_budget is absent, then the router has honored pick. If it's present, then the gate is active. If you have it set to 0, then that means it's never pay to switch. If it's set to "unlimited" (a string value), it makes more developer experience sense to just remove it to disable it.

  • Could you please try some examples to study how the routing_budget affects the user experience and give a guideline on how to set the value?

Will report back on this.

@nehcgs

nehcgs commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@Spherrrical We can't have this refund logic.

  • With refunds, the router can switch to a cheap model to earn credit, then spend it after switching back. Cost nets to ~zero, but switch count becomes unbounded. That's the exact failure the budget exists to prevent.
  • The switch cost is the money developers actually pay for context re-reading. The "saving" is measured against a path we didn't take. Refunding hypothetical savings means spending "imaginary" money.
  • In some cases, routing can save money for requests. But this refund logic encourages to spend such savings on routing by selecting expensive yet "affordable" models.

Replace the absolute seed_usd pool with max_overhead_pct (whole-number
percent) measured against the session's running never-switch baseline:
allow a paid switch only while cumulative switch spend stays within
max_overhead_pct% of what staying on the anchor would have cost. Track
baseline_usd and switch_spend_usd on the binding (monotonic, no refunds).
…head cap

Rename the switch-decision reasons (within_budget/over_budget -> within_cap/
over_cap) and the session span attributes to match the percentage overhead-cap
model: budget_remaining_in_usd -> overhead_pct + switch_spend_in_usd +
baseline_in_usd, and switch threshold_in_usd -> overhead_ceiling_in_usd.
Price each turn from the catalog rates and surface it as llm.usage.{input,
output,total}_cost_usd on the (llm) span, then accumulate into a conversation-
level session_cost_usd on the binding and emit plano.session.total_cost_in_usd
on the routing span. Cache-creation tokens are priced at the plain input rate,
and the OpenAI vs Anthropic prompt-token convention (cached folded in vs
reported separately) is captured at parse time so the cost math is correct for
both. Rates are resolved request-side since the response path is synchronous.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Why est_context_tokens? We should use actual context tokens not the estimated ones?
  • If I understand correctly, anchor_model here refers to the default model, not the one the request was sent to? The formula should be:
    switch_cost_in_usd = context_tokens × (candidate_uncached_input_rate − current_cached_input_rate) / 1M

current_cached_input_rate is calculated based on the model that the request was sent to, and never-switch baseline should be calculated based on the default model for the session.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • rename estimate_context_tokens -> actual_context_tokens. This is not an estimation.
  • rename max_overhead_pct -> max_routing_overhead?
  • distinguish between default_model for the session and anchor_model for the one that handles the latest previous request

@knn-do knn-do left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Spherrrical
I love the detailed documentation, thanks for adding support for the counterfactual measurement based on my request as well.

I reviewed the updated code again & have some more requests:

  1. Can we structure the code to allow other cache-aware routing policies besides this one? The current policy is too sensitive to the initial / local prompt & may stay in a sub-optimal route for an extended period of time. We need to fundamentally try different policies & measure impact to make data driven decisions here.
  2. Can we keep track of historical routes in the current session (bounded)? This can not only allow for new kinds of policies it should also allow for better switching cost estimates (explained below)
  3. The cache switching cost estimate may be too poor of an approximation & perhaps worth fixing based on data ^. And I don't just mean the fact that the tokenizer varies wildly between models (eg OAI is much more efficient than OpenModels & ANT). If we switch from model A -> B -> A, the switching cost is not the entire context that should be considered.

Overall, I want to understand what is the best way to test a bunch of policies quickly rather than have you work on documentation testing & observability for each.
We now have a way to measure quality / cost tradeoffs for router systematically.

I also want @salmanap 's thoughts on whether we want to maintain multiple strategies in Plano longer term or just keep the best here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants